SPDX-FileCopyrightText: 2026 Luna Faundez & Hadrien Chevalier SPDX-FileCopyrightText: 2026 AlICe laboratory https://alicelab.be
SPDX-License-Identifier: GPL-3.0-or-later
Written on Windows 11 with Blender 5.0.0 04 06 2026
import math
import os
import random
import bmesh
import bpy
import mathutils
from mathutils.bvhtree import BVHTree
os.system("clear")for i in range(1, 5):
for _name in ("Loop_" + str(i), "deform_" + str(i)):
_old = bpy.data.objects.get(_name)
if _old is not None:
bpy.data.objects.remove(_old, do_unlink=True)
for _obj in list(bpy.data.objects):
if _obj.name.startswith("Processed_pipe_"):
bpy.data.objects.remove(_obj, do_unlink=True)
_old_proc = bpy.data.collections.get("PROCESSED_PIPES")
if _old_proc is not None:
bpy.data.collections.remove(_old_proc)
bpy.ops.outliner.orphans_purge()loops_coll = bpy.data.collections["LOOPS"]
if len(loops_coll.objects) == 0:
print("The 'LOOPS' collection is empty.")
wave_coll = bpy.data.collections["WAVE_FORM"]
wave_curves = [o for o in wave_coll.objects if o.type == "CURVE"]
if len(wave_curves) == 0:
print("No CURVE object found in the 'WAVE_FORM' collection.")proportionality_values = {
"A": 3,
"B": 3,
"C": 2,
"D": 5,
"E": 3,
"F": 3,
"G": 2,
"H": 2,
"I": 4,
"J": 1,
"K": 2,
"L": 4,
}
for obj in loops_coll.objects:
if obj.name in proportionality_values:
obj["proportionality_" + obj.name] = proportionality_values[obj.name]MAKE_LOOP FUNCTION ----------------------------------------------------------------------------
Builds one ‘Loop_
def make_loop(_index):— 1) pick a random object from ‘LOOPS’, copy it -> Loop_
source_loop = random.choice(list(loops_coll.objects)) # random pick
print("Loop_" + str(_index), "-> picked loop :", source_loop.name)
Loop = source_loop.copy() # copies the object
Loop.data = source_loop.data.copy() # deep-copies the mesh
Loop.name = "Loop_" + str(_index)
Loop.data.name = "Loop_" + str(_index) + "_mesh"
bpy.context.collection.objects.link(Loop) # link it into the scene
Loop.location = (0, 0, 0) # absolute origin
Loop.rotation_euler.x = math.radians(
90,
) # 90 deg turn (X axis), applied BEFORE any modifier— 2) pick a random curve from ‘WAVE_FORM’, copy it -> deform_
source_curve = random.choice(wave_curves) # random pick
print("Loop_" + str(_index), "-> picked curve :", source_curve.name)
deform = source_curve.copy()
deform.data = source_curve.data.copy()
deform.name = "deform_" + str(_index)
deform.data.name = "deform_" + str(_index) + "_data"
bpy.context.collection.objects.link(deform)
deform.location = (0, 0, 0) # absolute origin— 3) curve deform modifier on Loop_
curve_mod = Loop.modifiers.new(name="Curve_Deform", type="CURVE")
curve_mod.object = deform
curve_mod.deform_axis = "POS_X" # axis mapped onto the curve (try POS_Y / POS_Z)— 4) bake the deformation, then extrude the shape by 10 along ABSOLUTE Y ------------------ Applying the modifier
bpy.ops.object.select_all(action="DESELECT")
Loop.select_set(True)
bpy.context.view_layer.objects.active = Loop
bpy.ops.object.modifier_apply(modifier=curve_mod.name)Extruding the freshly deformed geometry.
myObjGeom = Loop.data
myBmesh = bmesh.new()
myBmesh.from_mesh(myObjGeom)
myBmesh.faces.ensure_lookup_table()extruding the whole region – bmesh equivalent of pressing E with everything selected
geom_in = list(myBmesh.faces)
myExtrusion = bmesh.ops.extrude_face_region(myBmesh, geom=geom_in)filter the freshly created vertices so we only translate the new geometry
ExtrudedVerts = [
v for v in myExtrusion["geom"] if isinstance(v, bmesh.types.BMVert)
]translate them by 10 along the ABSOLUTE Y axis. Loop carries a 90 deg rotation, so its local Y is no longer aligned with world Y. The bmesh translate works in the mesh’s local space, so we convert the world Y vector into the loop’s local space first.
world_vec = mathutils.Vector((0, 10, 0))
local_vec = Loop.matrix_world.inverted().to_3x3() @ world_vec
bmesh.ops.translate(
myBmesh,
verts=ExtrudedVerts,
vec=local_vec,
)
myBmesh.to_mesh(myObjGeom)
myObjGeom.update()
myBmesh.free()— 5) all modifiers applied -> reset origin to the CENTER OF VOLUME ------------------------
bpy.ops.object.select_all(action="DESELECT")
Loop.select_set(True)
bpy.context.view_layer.objects.active = Loop
bpy.ops.object.origin_set(type="ORIGIN_CENTER_OF_VOLUME")
return Loop, deform, source_looploops = {}
deforms = {}
chosen_sources = {}
for i in (1, 2, 3, 4):
loops[i], deforms[i], chosen_sources[i] = make_loop(i)total_proportionality = 0
for i in (1, 2, 3, 4):
src = chosen_sources[i]
total_proportionality += src.get(
"proportionality_" + src.name,
0,
) # .get -> 0 if the prop is missing
print("total_proportionality =", total_proportionality)for i in (2, 4):
loops[i].rotation_euler.z += math.radians(90)two random rotations for every loop EXCEPT Loop_1 :
for i in (2, 3, 4):
loops[i].rotation_euler.x += math.radians(random.uniform(-30, 30))
loops[i].rotation_euler.y += math.radians(random.uniform(-30, 30))move Loop_3 and Loop_4 up 10 units along the ABSOLUTE Z axis
for i in (3, 4):
loops[i].location.z += 10center every loop on the ABSOLUTE X=0 / Y=0 axis (Z is left untouched). Each origin is the center of volume, so this aligns all loops on the world Z axis.
for i in (1, 2, 3, 4):
loops[i].location.x = 0
loops[i].location.y = 0selected_loops_coll = bpy.data.collections["Selected_loops"]
deforming_curves_coll = bpy.data.collections["deforming_curves"]def move_to_collection(obj, target_coll):
for c in list(obj.users_collection):
c.objects.unlink(obj)
target_coll.objects.link(obj)
for i in (1, 2, 3, 4):
move_to_collection(loops[i], selected_loops_coll)
move_to_collection(deforms[i], deforming_curves_coll)
bpy.ops.object.select_all(action="DESELECT")pipes_coll = bpy.data.collections["PIPES"]
processed_coll = bpy.data.collections.new(
"PROCESSED_PIPES"
) # create the new collection
bpy.context.scene.collection.children.link(processed_coll) # link it into the scene
processed_pipes = []
for n, pipe in enumerate(sorted(pipes_coll.objects, key=lambda o: o.name), start=1):
new_pipe = pipe.copy() # copy the object
new_pipe.data = pipe.data.copy() # deep-copy its data
new_pipe.name = "Processed_pipe_" + str(n)
new_pipe.data.name = "Processed_pipe_" + str(n) + "_data"
processed_coll.objects.link(new_pipe) # link into PROCESSED_PIPES
processed_pipes.append(new_pipe)for pipe in processed_pipes:
pipe.location.y += random.uniform(-0.5, 0.5)
pipe.location.z += random.uniform(-0.5, 0.5)def bvh_from_object(obj):
bm = bmesh.new()
bm.from_mesh(obj.data)
bm.transform(obj.matrix_world) # local -> world coordinates
bvh = BVHTree.FromBMesh(bm)
bm.free()
return bvhdef meshes_intersect(obj1, obj2):
bvh1 = bvh_from_object(obj1)
bvh2 = bvh_from_object(obj2)
return len(bvh1.overlap(bvh2)) > 0 # True if the two hierarchies overlapdef boolean_difference(loop_obj, pipe_obj):‘generate’ modifier : a Boolean set to DIFFERENCE (loop minus pipe). Added live here; every boolean is APPLIED afterwards, once all of them are in place, so the cutter pipes still exist at apply-time even when one pipe carves several loops.
bool_mod = loop_obj.modifiers.new(name="Bool_" + pipe_obj.name, type="BOOLEAN")
bool_mod.operation = "DIFFERENCE"
bool_mod.object = pipe_obj
bool_mod.solver = "MANIFOLD"banded result driven by total_proportionality, REUSED for both the pipes and the collision cubes :
if 4 <= total_proportionality < 8:
band_count = 1
elif 8 <= total_proportionality < 12:
band_count = 2
elif 12 <= total_proportionality < 16:
band_count = 3
elif 16 <= total_proportionality <= 20:
band_count = 4
else:
band_count = 0
n_chosen = min(band_count, len(processed_pipes))
chosen_pipes = random.sample(processed_pipes, n_chosen)
print("chosen pipes for collision :", [p.name for p in chosen_pipes])for each chosen pipe : test it against every loop, and on collision carve that loop with a boolean difference
used_cutters = set()
for pipe in chosen_pipes:
for i in (1, 2, 3, 4):
loop_obj = loops[i]
if meshes_intersect(loop_obj, pipe):
print(pipe.name, "collides with", loop_obj.name, "-> boolean difference")
boolean_difference(loop_obj, pipe)
used_cutters.add(pipe)apply every boolean modifier
for i in (1, 2, 3, 4):
loop_obj = loops[i]
bool_names = [m.name for m in loop_obj.modifiers if m.type == "BOOLEAN"]
if bool_names:
bpy.ops.object.select_all(action="DESELECT")
loop_obj.select_set(True)
bpy.context.view_layer.objects.active = loop_obj
for name in bool_names:
bpy.ops.object.modifier_apply(modifier=name)now that all cuts are baked, delete the cutting objects
for pipe in list(used_cutters):
print("deleting cutter", pipe.name)
bpy.data.objects.remove(pipe, do_unlink=True)
bpy.ops.object.select_all(action="DESELECT")COLLISION CUBES Reuse the SAME banded value (band_count, 1..4) as the random quantity of cubes to pull from ‘COLLISION_CUBES’. Each chosen cube is collision-tested against EVERY object currently in ‘PROCESSED_PIPES’ and ‘Selected_loops’, and carves (boolean difference) the ones it collides with.
collision_cubes_coll = bpy.data.collections["COLLISION_CUBES"]
all_cubes = list(collision_cubes_coll.objects)
n_cubes = min(band_count, len(all_cubes)) # safety clamp (band_count is 1..4, 5 cubes)
chosen_cubes = random.sample(all_cubes, n_cubes)
print("chosen collision cubes :", [c.name for c in chosen_cubes])targets = every object still present in PROCESSED_PIPES + every loop in Selected_loops
cube_targets = list(processed_coll.objects) + list(selected_loops_coll.objects)add a boolean difference on every target a cube collides with (target minus cube)
for cube in chosen_cubes:
for target in cube_targets:
if meshes_intersect(target, cube):
print(cube.name, "cuts", target.name, "-> boolean difference")
boolean_difference(target, cube)apply the cube booleans
for target in cube_targets:
bool_names = [m.name for m in target.modifiers if m.type == "BOOLEAN"]
if bool_names:
bpy.ops.object.select_all(action="DESELECT")
target.select_set(True)
bpy.context.view_layer.objects.active = target
for name in bool_names:
bpy.ops.object.modifier_apply(modifier=name)
bpy.ops.object.select_all(action="DESELECT")print("""
** ** ** **
**** **** **** ****
********* thank *********
******* you *******
***** Denis *****
*** & ***
* Michel *
""")